/**
 * @fileoverview Logic for the Dice Tray App.
 * @author Kenneth Cummins
 *
 *	Dice are defined via ([0-9]*)d((F|[0-9]+))(x[0-9]*)?(s[0-9]*((b[0-9]*)?))(h[0-9]?)?(l[0-9]?)?
 */

/**
 * Whether to display debug information in the app.
 * @type {boolean}
 * @const
 */
var DEBUG = false;

/**
 * A list of the participants.
 * @type {Array.<gapi.hangout.Participant>}
 * @private
 */
var participants_ = null;

/**
 * Maps Participant's hangoutIds to their current public trays.
 * @type {Object.<!string, !DiceTray>}
 * @private
 */
var dicetrayMap_ = null;

/**
 * A map of names to jQuery elements which compose the app.
 * @type {Object.<string, jQuery>}
 * @private
 */
var DOM_ = {
  body: null,
  canvas: null,
  dicetrayList: null
};

/**
 * Makes an RPC call to store the given value(s) in the shared state.
 * @param {!(string|Object.<!string, !string>)} keyOrState Either an object
 *     denoting the desired key value pair(s), or a single string key.
 * @param {!string} opt_value If keyOrState is a string, the associated value.
 */
var saveValue = null;

/**
 * Makes an RPC call to remove the given value(s) from the shared state.
 * @param {!(string|Array.<!string>)} keyOrListToRemove A single key
 *     or an array of strings to remove from the shared state.
 */
var removeValue = null;

(function() {
  /**
   * Packages the parameters into a delta object for use with submitDelta.
   * @param {!(string|Object.<!string, !string>)}  Either an object denoting
   *     the desired key value pair(s), or a single string key.
   * @param {!string} opt_value If keyOrState is a string, the associated string
   *     value.
   */
  var prepareForSave = function(keyOrState, opt_value) {
    var state = null;
    if (typeof keyOrState === 'string') {
      state = {};
      state[keyOrState] = opt_value;
    } else if (typeof keyOrState === 'object' && null !== keyOrState) {
      // Ensure that no prototype-level properties are hitching a ride.
      state = {};
      for (var key in keyOrState) {
        if (keyOrState.hasOwnProperty(key)) {
          state[key] = keyOrState[key];
        }
      }
    } else {
      throw 'Unexpected argument.';
    }
    return state;
  };

  /**
   * Packages one or more keys to remove for use with submitDelta.
   * @param {!(string|Array.<!string>)} keyOrListToRemove A single key
   *     or an array of strings to remove from the shared state.
   * @return {!Array.<!string>} A list of keys to remove from the shared state.
   */
  var prepareForRemove = function(keyOrListToRemove) {
    var delta = null;
    if (typeof keyOrListToRemove === 'string') {
      delta = [keyOrListToRemove];
    } else if ($.isArray(keyOrListToRemove)) {
      // Discard non-string elements.
      for (var i = 0, iLen = keyOrListToRemove.length; i < iLen; ++i) {
        if (typeof keyOrListToRemove[i] === 'string') {
          delta.push(keyOrListToRemove[i]);
        }
      }
    } else {
      throw 'Unexpected argument.';
    }
    return delta;
  };

  /**
   * Makes an RPC call to add and/or remove the given value(s) from the shared
   * state.
   * @param {?(string|Object.<!string, !string>)} addState  Either an object
   *     denoting the desired key value pair(s), or a single string key.
   * @param {?(string|Object.<!string, !string>)} opt_removeState A list of keys
   *     to remove from the shared state.
   */
  var submitDeltaInternal = function(addState, opt_removeState) {
    gapi.hangout.data.submitDelta(addState, opt_removeState);
  };

  saveValue = function(keyOrState, opt_value) {
    var delta = prepareForSave(keyOrState, opt_value);
    if (delta) {
      submitDeltaInternal(delta);
    }
  };

  removeValue = function(keyOrListToRemove) {
    var delta = prepareForRemove(keyOrListToRemove);
    if (delta) {
      submitDeltaInternal({}, delta);
    }
  };
})();


/**
 * Syncs local copies of shared state with those on the server and renders the
 *     app to reflect the changes.
 * @param {!Array.<Object.<!string, *>>} add Entries added to the shared state.
 * @param {!Array.<!string>} remove Entries removed from the shared state.
 * @param {!Object.<!string, !string>} state The shared state.
 * @param {!Object.<!string, Object.<!string, *>>} metadata Data describing the
 *     shared state.
 */
function onStateChanged(add, remove, state, metadata) {
  // Update dicetrayMap_
  dicetrayMap_ = dicetrayMap_ || {};
  for (var i = 0, iLen = add.length; i < iLen; ++i) {
    var hangoutId = getHangoutIdFromUserKey_(add[i].key);
    if (hangoutId) {
      var dicetray = DiceTray.deserialize(add[i].value);
      var currDiceTray = dicetrayMap_[hangoutId];
      if (dicetray && (!currDiceTray || dicetray.getId() !== currDiceTray.getId())) {
        dicetrayMap_[hangoutId] = dicetray;
      }
    }
  }

  for (var i = 0, iLen = remove.length; i < iLen; ++i) {
    var hangoutId = getHangoutIdFromUserKey_(remove[i]);
    if (hangoutId && dicetrayMap_[hangoutId]) {
      delete dicetrayMap_[hangoutId];
    }
  }

  setDiceTrays();
}

/**
 * Syncs local copy of the participants list with that on the server and renders
 *     the app to reflect the changes.
 * @param {!Array.<gapi.hangout.Participant>} participants The new list of
 *     participants.
 */
function onParticipantsChanged(participants) {
  participants_ = participants;
  setDiceTrays();
}

/**
 * Class representing an individual die.
 * @param {!Object.<!string, *>} spec Values for the Die. This must contain
 *     a definition key.
 * @constructor
 */
function Dice(spec) {
  spec = spec || {};

  var createGetter = function(initialVal) {
    var value = initialVal;
    return function() {
      return value;
    }
  };

  if (!spec.def) {
    throw 'Dice must be defined.';
  } else {
	this.getDef = createGetter(spec.def);
	this.random = (spec.rng ? spec.rng.uint32 : null) || function() { return Math.floor(Math.random() * 0x100000000); }
    var vp = new RegExp("^([0-9]*)d(F|([0-9]+)([xsbhl][0-9]*)?([xsbhl][0-9]*)?([xsbhl][0-9]*)?([xsbhl][0-9]*)?([xsbhl][0-9]*)?)$");
    var m = vp.exec(spec.def);
	if (m == null) {
		throw 'Invalid dice definition.';
	}
	this.getCount = createGetter(m[1] || 1);
	if (m[2] == 'F') {
		this.getFaces = createGetter(3);
		this.getSuccess = createGetter(3);
		this.getBotch = createGetter(1);
	} else {
		this.getFaces = createGetter(m[3]);
		var dp = new RegExp("^([xsbhl])([0-9]*)$");
		for (i = 4; i < m.length; i++) {
			kv = dp.exec(m[i]);
			if(kv != null) {
				k = kv[1];
				v = kv[2];
				if(k == 'x') {
					this.getExplode = createGetter(v || this.getFaces());
				} else if(k == 's') {
					this.getSuccess = createGetter(v || this.getFaces());
				} else if(k == 'b') {
					this.getBotch = createGetter(v || 1);
				} else if(k == 'h') {
					if(this.getLowest != null) {
						throw 'Can\'t keep highest and lowest rolls.';
					}
					this.getHighest = createGetter(v || 1);
				} else if(k == 'l') {
					if(this.getHighest != null) {
						throw 'Can\'t keep lowest and highest rolls.';
					}
					this.getLowest = createGetter(v || 1);
				}
			}
		}
	}
  }

  this.getDef = createGetter(spec.def);

  var serialObject = {
    'def': this.getDef()		// XdN and variants
  };

  this.serialize = createGetter(JSON.stringify(serialObject));
  
}
/**
 * @return {int} Get the number of dice to roll.
 */
Dice.prototype.getCount = null;
/**
 * @return {int} Get the target to trigger an additional roll.
 */
Dice.prototype.getExplode = null;
/**
 * @return {int} Get the target to count the die as a success.
 */
Dice.prototype.getSuccess = null;
/**
 * @return {int} Get the target to count the die as a botch.
 */
Dice.prototype.getBotch = null;
/**
 * @return {int} Get the number of highest values to keep.
 */
Dice.prototype.getHighest = null;
/**
 * @return {int} Get the number of lowest values to keep.
 */
Dice.prototype.getLowest = null;
/**
 * @return {string} The definition displayed for the Die.
 */
Dice.prototype.getDef = null;
/**
 * Serializes the Die to a string for sending to the shared state.
 * @return {string} The serialized representation of the Die.
 */
Dice.prototype.serialize = null;
/**
 * @return {string} A string representation of the Die useful for debugging.
 */
Dice.prototype.toString = function() {
  return '[Object Dice <' + this.serialize() + '>]';
};
/**
 * Create an Die from a serialized Die representation.
 * @param {string} serializedForm A string representing an Die object.
 * @return {?Die} A new Die populated using the data in the given
 *     serializedForm.
 */
Dice.deserialize = function(serializedForm) {
  if (typeof serializedForm !== 'string') {
    return null;
  }
  var dieSpec = $.parseJSON(serializedForm);
  return new Dice(dieSpec);
};

Dice.prototype.roll = function() {
	
	var results = {};
	results.def = this.getDef();
	results.raw = new Array();
	results.results = new Array();
	if(this.getSuccess) {
	  results.successes = 0;
	}
	if(this.getExplode) {
	  results.explodes = 0;
	}
	for(i=0;i<this.getCount(); i++) {
		x = (this.random() % this.getFaces()) + 1;
		this.getSuccess && x >= this.getSuccess() && results.successes++;
		this.getBotch && x <= this.getBotch() && results.successes--;
		results.raw.push(x);
		results.results.push(x);
		while(this.getExplode && x >= this.getExplode()) {
			results.explodes++;
			x = (this.random() % this.getFaces()) + 1;
			this.getSuccess && x >= this.getSuccess() && results.successes++;
			this.getBotch && x <= this.getBotch() && results.successes--;
			results.raw.push(x);
			results.results.push(x);
		}
	}
	if(this.getHighest || this.getLowest) {
		results.results.sort(function(a,b) { return a-b; });
		if(this.getHighest) {
			results.results = results.results.slice(0 - this.getHighest());
		} else {
			results.results = results.results.slice(0, this.getLowest() - 1);
		}
	}
	return results;
}


/**
 * Class representing a dice tray.
 * @param {!Object.<!string, *>} spec Values for the Die. This must contain
 *     an id key.
 * @constructor
 */
function DiceTray(spec) {
  spec = spec || {};

  if (!spec.id) {
    throw 'Dice Trays must define a unique id.';
  }

  var createGetter = function(initialVal) {
    var value = initialVal;
    return function() {
      return value;
    }
  };

  this.getId = createGetter(spec.id);
  this.getName = createGetter(spec.name || '');
  this.getDice = createGetter(spec.dice || '');
  this.getUrl = createGetter(spec.url || '');

  var serialObject = {
    'id': this.getId(),
    'name': this.getName(),
    'dice': this.getDice(),
    'url': this.getUrl()
  };

  this.serialize = createGetter(JSON.stringify(serialObject));
}

/**
 * @return {string} The identifier for this DiceTray.
 */
DiceTray.prototype.getId = null;

/**
 * @return {string} The title displayed for the DiceTray.
 */
DiceTray.prototype.getName = null;

/**
 * @return {string} The url of the image displayed when the user is not
 *     speaking.
 */
DiceTray.prototype.getRestUrl = null;

/**
 * @return {string} The url of the image displayed when the user is speaking.
 */
DiceTray.prototype.getTalkUrl = null;

/**
 * Serializes the DiceTray to a string for sending to the shared state.
 * @return {string} The serialized representation of the DiceTray.
 */
DiceTray.prototype.serialize = null;

/**
 * @return {string} A string representation of the DiceTray useful for debugging.
 */
DiceTray.prototype.toString = function() {
  return '[Object DiceTray <' + this.serialize() + '>]';
};

/**
 * Create an DiceTray from a serialized DiceTray representation.
 * @param {string} serializedForm A string representing an DiceTray object.
 * @return {?DiceTray} A new DiceTray populated using the data in the given
 *     serializedForm.
 */
DiceTray.deserialize = function(serializedForm) {
  if (typeof serializedForm !== 'string') {
    return null;
  }
  var dicetraySpec = $.parseJSON(serializedForm);
  return new DiceTray(dicetraySpec);
};

/**
 * @return {Array.<!Die>} A list of possible Die choices.
 */
function getDieChoices() {
  var imageHost = '//sites.google.com/site/kvcummins/apps/lemurbot/static';
  var android = new DiceTray({
      'id': 'android',
      'name': 'Android',
      'restUrl': imageHost + '/android_logo_closed.png',
      'talkUrl': imageHost + '/android_logo_open.png'
  });
  var androidBlue = new DiceTray({
      'id': 'androidBlue',
      'name': 'Android Blue',
      'restUrl': imageHost + '/android_blue_closed.png',
      'talkUrl': imageHost + '/android_blue_open.png'
  });

  return [android, androidBlue];
}

/**
 * @return {string} The user's ephemeral id.
 */
function getUserHangoutId() {
  return gapi.hangout.getParticipantId();
}

/**
 * @return {boolean} Whether the local Participant's mic is muted.
 */
function isUserMicMuted() {
  return gapi.hangout.av.getMicrophoneMute();
}

/**
 * Gets a userKey from the hangoutId. This is the opposite operation of
 * getHangoutIdFromUserKey_.
 * @param {string} hangoutId The user's hangout id.
 * @return {string} A userKey for use in the app state.
 * @see #getHangoutIdFromUserKey_
 * @private
 */
function makeUserKey_(hangoutId) {
  return hangoutId + ':dicetray';
}

/**
 * Gets the user's hangoutId from the userKey. This is the oppposite operation
 * of makeUserKey_.
 * @return {?string} The user's hangoutId, or null if the userKey isn't
 *     correctly formatted.
 * @see #makeUserKey_
 * @private
 */
function getHangoutIdFromUserKey_(userKey) {
  if (typeof userKey === 'string') {
    var idx = userKey.lastIndexOf(':');

    if (idx >= 0) {
      if ('dicetray' === userKey.substr(idx + 1)) {
        return userKey.substr(0, idx);
      }
    }
  }
  return null;
}

/**
 * Populates the list of DiceTray choices.
 * @param {!Array.<!DiceTray>} dicetrayChoices A list of possible DiceTray choices.
 */
function populateDiceTrayChoices(dicetrayChoices) {

  var createDiceTrayClickHandler = function(dicetray) {
    var hangoutId = getUserHangoutId();
    var newAv = dicetray.serialize();
    var key = makeUserKey_(hangoutId);
    return function() {
      var currAv = dicetrayMap_[hangoutId];
      var isRemove = currAv && currAv.getId() === dicetray.getId();

      if (isRemove) {
        removeValue(key);
      } else {
        saveValue(key, newAv);
      }
    };
  };

  for (var i = 0, iLen = dicetrayChoices.length; i < iLen; ++i) {
    var curr = adicetrayChoices[i];
    var dicetrayThumb = $('<img />')
        .attr({
          'title': curr.getName() + ' thumbnail',
          'src': curr.getRestUrl()
        });
    var dicetrayText = $('<div />')
        .text(curr.getName());
    var dicetrayItem = $('<li />')
        .attr('id', curr.getId())
        .append(dicetrayText)
        .append(dicetrayThumb)
        .click(createDiceTrayClickHandler(curr));

    DOM_.dicetrayList.append(dicetrayItem);
  }
}

/**
 * Apply selected dicetrays to their respective participants.
 */
function setDiceTrays() {
  if (!dicetrayMap_ || !participants_) {
    return;
  }

  if (DEBUG) {
    var table = DOM_.debugTable;
    table.empty();
  }

  var createSetDiceTrayHandler = function(hangoutId, isTalking, imgUrl) {
    return function() {
      if (isTalking !== this.isTalking) {
        this.isTalking = isTalking;
        gapi.hangout.av.setDiceTray(hangoutId, imgUrl);
      }
    }
  };

  for (var i = 0, iLen = participants_.length; i < iLen; ++i) {
    var p = participants_[i];
    var isCurrentUser = p.hangoutId === getUserHangoutId();
    var clearSelected = false, setSelected = false;
    if (dicetrayMap_[p.hangoutId]) {
      var dicetray = dicetrayMap_[p.hangoutId];
      if (!dicetray.isSet) {
        clearSelected = setSelected = isCurrentUser;

        dicetray.talkHandler =
            createSetAvatarHandler(p.hangoutId, true, dicetray.getTalkUrl());
        dicetray.quietHandler =
            createSetAvatarHandler(p.hangoutId, false, dicetray.getRestUrl());
        dicetray.isSet = true;
        dicetray.quietHandler();

        if (DEBUG) {
          var tdKey = $('<td />')
              .text(p.hangoutId);
          var tdVal = $('<td />').text(dicetray.toString());
          var tr = $('<tr />')
              .attr('id', p.hangoutId)
              .append(tdKey, tdVal);
          table.append(tr);
        }
      }
    } else {
      // If the dicetray is currently set, remove it.
      gapi.hangout.av.clearDiceTray(p.hangoutId);
      clearSelected = isCurrentUser;
    }
    if (clearSelected) {
      DOM_.dicetrayList.find('li').removeClass('selected');
    }
    if (setSelected) {
      $('#' + dicetray.getId()).addClass('selected');
    }
  }
}

/**
 * Create required DOM elements and listeners.
 */
function prepareAppDOM() {
  DOM_.dicetrayList = $('<ul />')
      .attr('id', 'dicetrayList');
  DOM_.canvas = $('<div />')
      .attr('id', 'canvas')
      .append(DOM_.dicetrayList);

  DOM_.body = $('body')
      .append(DOM_.canvas);

  if (DEBUG) {
    DOM_.debugTable = $('<table />');
    DOM_.body.append(DOM_.debugTable);
  }
}

/**
 * Displays DiceTray "talking" when a Participant's volume level changes.
 * @param {!Object.<!string, !number>} volumes A map of hangoutId > mic volume,
 *     where volume is in the range [0, 5].
 */
function onVolumeLevelsChanged(volumes) {
  if (!participants_ || !dicetrayMap_) {
    return;
  }
  for (var i = 0, iLen = participants_.length; i < iLen; ++i) {
    var hangoutId = participants_[i].hangoutId;
    var level = volumes[hangoutId] || 0;
    var dicetray = dicetrayMap_[hangoutId];
    if (dicetray && dicetray.talkHandler && dicetray.quietHandler) {
      var isLocalAndMuted = hangoutId === getUserHangoutId() &&
          isUserMicMuted();
      var myLevel = dicetray.level || 0;
      if (isLocalAndMuted || level < myLevel) {
        dicetray.quietHandler();
      } else if (level > myLevel) {
        dicetray.talkHandler();
      }
      dicetray.level = level;
    }
  }
}

/**
 * Initialize the DOM and app data.
 */
(function() {
  if (gapi && gapi.hangout) {

    var initHangout = function() {
      prepareAppDOM();

      gapi.hangout.data.addStateChangeListener(onStateChanged);
      gapi.hangout.addParticipantsListener(onParticipantsChanged);

      populateDiceTrayChoices(getDiceTrayChoices());

      if (!participants_) {
        var initParticipants = gapi.hangout.getParticipants();
        if (initParticipants) {
          onParticipantsChanged(initParticipants);
        }
      }
      if (!dicetrayMap_) {
        var initState = gapi.hangout.data.getState();
        var initMetadata = gapi.hangout.data.getStateMetadata();
        // Since this is the first push, added has all the values in metadata in
        // Array form.
        var added = [];
        for (var key in initMetadata) {
          if (initMetadata.hasOwnProperty(key)) {
            added.push(initMetadata[key]);
          }
        }
        var removed = [];
        if (initState && initMetadata) {
          onStateChanged(added, removed, initState, initMetadata);
        }
      }

      gapi.hangout.av.addVolumesChangedListener(onVolumeLevelsChanged);

      gapi.hangout.removeApiReadyListener(initHangout);
    };

    gapi.hangout.addApiReadyListener(initHangout);
  }
})();
